spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { WorldMap } from '@/components/map/world-map';5import { Block, DL, Empty, Head, Row } from '@/components/satellite/primitives';6import { OrbitBadge, StatusBadge, TypeBadge } from '@/components/ui/badges';7import { Container, Stat } from '@/components/ui/section';8import { api, ApiError } from '@/lib/api';9import { fmtDate, fmtDateTime, fmtDeg, fmtInt, num, titleCase } from '@/lib/format';10import { EVENT_TYPE_LABELS, OBJECT_TYPE_LABELS, routes, SITE_URL } from '@/lib/site';11import type { LaunchDetail } from '@/lib/types';1213type Params = { params: Promise<{ cospar: string }> };14const TYPE_ORDER = ['PAYLOAD', 'STATION', 'CREWED', 'ROCKET_BODY', 'DEBRIS', 'UNKNOWN'];1516async function load(cospar: string): Promise<LaunchDetail> {17 try {18 return (await api.launch(cospar)).data;19 } catch (e) {20 if (e instanceof ApiError && e.notFound) notFound();21 throw e;22 }23}2425function describe(l: LaunchDetail): string {26 return `Launch ${l.cospar_launch_id}${l.primary_name ? ` (${l.primary_name})` : ''} on ${fmtDate(l.launch_date)}${l.site_name ? ` from ${l.site_name}` : ''}: ${fmtInt(l.payload_count)} payloads, ${fmtInt(l.object_count)} catalogued objects, ${fmtInt(l.on_orbit_count)} still on orbit. Every object with its status, orbit and owner on SatelliteIndex.`;27}2829export async function generateMetadata({ params }: Params): Promise<Metadata> {30 const { cospar } = await params;31 let l: LaunchDetail;32 try {33 l = (await api.launch(cospar)).data;34 } catch {35 return { title: 'Launch', robots: { index: false } };36 }37 const title = `${l.primary_name ?? 'Launch'} — Launch ${l.cospar_launch_id}, ${fmtDate(l.launch_date)}`;38 const description = describe(l);39 const canonical = routes.launch(l.cospar_launch_id);40 return { title, description, alternates: { canonical }, openGraph: { title, description, url: `${SITE_URL}${canonical}`, type: 'article' }, twitter: { card: 'summary', title, description } };41}4243function OwnerChips({ owners }: { owners: LaunchDetail['owners'] }) {44 return (45 <ul className="flex flex-wrap gap-1.5">46 {owners.map((o) => (47 <li key={o.code}>48 <Link href={routes.launches(`owner=${encodeURIComponent(o.code)}`)} className="inline-flex min-h-9 items-center gap-1.5 rounded-md border border-rule px-2.5 py-1 text-xs text-ink-2 hover:border-rule-strong hover:text-ink" title={`${o.kind} · all launches with owner ${o.code}`}>49 <span className="mono text-ink">{o.code}</span> {o.name}50 </Link>51 </li>52 ))}53 </ul>54 );55}5657export default async function LaunchPage({ params }: Params) {58 const { cospar } = await params;59 const l = await load(cospar);60 const objects = num(l.object_count) ?? l.objects.length;61 const onOrbit = num(l.on_orbit_count) ?? 0;62 const groups = TYPE_ORDER.map((t) => ({ type: t, rows: l.objects.filter((o) => o.object_type === t) })).filter((g) => g.rows.length);63 const hasSite = l.site_lat != null && l.site_lon != null;64 const jsonLd = { '@context': 'https://schema.org', '@type': 'Event', name: `Launch ${l.cospar_launch_id}${l.primary_name ? ` — ${l.primary_name}` : ''}`, startDate: l.launch_date, url: `${SITE_URL}${routes.launch(l.cospar_launch_id)}`, description: describe(l), location: l.site_name ? { '@type': 'Place', name: l.site_name, ...(hasSite ? { geo: { '@type': 'GeoCoordinates', latitude: l.site_lat, longitude: l.site_lon } } : {}) } : undefined, identifier: { '@type': 'PropertyValue', propertyID: 'COSPAR launch id', value: l.cospar_launch_id } };6566 return (67 <Container wide>68 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />69 <header className="pb-6 pt-6 md:pt-10">70 <p className="eyebrow mono">Launch · COSPAR {l.cospar_launch_id} · {fmtDate(l.launch_date)}</p>71 <h1 className="display mt-2 break-words text-3xl md:text-5xl">{l.primary_name ?? `Launch ${l.cospar_launch_id}`}</h1>72 <p className="mt-3 text-sm text-ink-2">73 {l.site_slug ? <Link href={routes.launchSite(l.site_slug)} className="link">{l.site_name}</Link> : l.site_name ?? 'Launch site unknown'}74 {l.site_country && <> · <Link href={routes.country(l.site_country)} className="link mono">{l.site_country}</Link></>}75 {hasSite && <span className="mono ml-2 text-xs text-ink-3">{l.site_lat!.toFixed(2)}°, {l.site_lon!.toFixed(2)}°</span>}76 </p>77 </header>7879 <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-start">80 <div className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-4">81 <Stat label="Payloads" value={fmtInt(l.payload_count)} />82 <Stat label="Catalogued objects" value={fmtInt(objects)} hint="payloads + rocket bodies + debris" />83 <Stat label="Still on orbit" value={fmtInt(onOrbit)} accent={onOrbit > 0} />84 <Stat label="Decayed" value={fmtInt(objects - onOrbit)} hint={objects ? `${((1 - onOrbit / objects) * 100).toFixed(0)}% of objects` : undefined} />85 <div className="col-span-2 sm:col-span-4">86 <p className="eyebrow mb-2">Owners (SATCAT owner codes)</p>87 {l.owners.length ? (88 <>89 <OwnerChips owners={l.owners.slice(0, 12)} />90 {l.owners.length > 12 && (91 <details className="mt-2">92 <summary className="inline-flex min-h-9 cursor-pointer items-center text-xs text-accent hover:underline">Show {fmtInt(l.owners.length - 12)} more owners</summary>93 <div className="mt-2"><OwnerChips owners={l.owners.slice(12)} /></div>94 </details>95 )}96 </>97 ) : (98 <Empty>No owner codes recorded for the objects of this launch.</Empty>99 )}100 </div>101 </div>102 <div className="overflow-hidden rounded-lg border border-rule">103 {hasSite ? (104 <WorldMap markers={[{ lat: l.site_lat!, lon: l.site_lon!, color: 'var(--accent)', size: 6, pulse: true, label: l.site_name ?? undefined, href: l.site_slug ? routes.launchSite(l.site_slug) : undefined }]} title={`Launch site: ${l.site_name ?? 'unknown'}`} />105 ) : (106 <p className="p-6 text-center text-sm text-ink-3">Launch site coordinates unavailable</p>107 )}108 </div>109 </div>110111 <div className="mt-6 divide-y divide-[color:var(--rule)]">112 <Block id="objects">113 <Head eyebrow="Catalogue" title={<>Objects from this launch <span className="tnum text-ink-3">· {fmtInt(l.objects.length)}{objects > l.objects.length ? ` of ${fmtInt(objects)}` : ''}</span></>} action={{ href: routes.satellites(`launch=${encodeURIComponent(l.cospar_launch_id)}`), label: 'Open in explorer' }} />114 {groups.length === 0 && <Empty>No catalogued object is linked to this launch.</Empty>}115 {groups.map((g) => {116 const collapsed = g.rows.length > 40;117 const table = (118 <div className="overflow-x-auto scrollbar-thin">119 <table className="data-table stack">120 <thead>121 <tr><th>Name</th><th>NORAD</th><th>COSPAR</th><th>Status</th><th>Orbit</th><th className="num">Perigee / apogee</th><th className="num">Incl.</th><th>Decayed</th><th>Operator</th></tr>122 </thead>123 <tbody>124 {g.rows.map((o) => (125 <tr key={o.id}>126 <td data-label="Name" className="primary"><Link href={routes.satellite(o.slug)} className="link font-medium">{o.name}</Link>{o.constellation_slug && <Link href={routes.constellation(o.constellation_slug)} className="ml-2 text-2xs text-ink-3 hover:text-accent">{o.constellation_name}</Link>}</td>127 <td data-label="NORAD" className="mono text-xs">{o.norad_id ?? '—'}</td>128 <td data-label="COSPAR" className="mono text-xs">{o.cospar_id ?? '—'}</td>129 <td data-label="Status"><StatusBadge status={o.status} /></td>130 <td data-label="Orbit"><OrbitBadge orbitClass={o.orbit_class} /></td>131 <td data-label="Perigee / apogee" className="num mono text-xs">{o.perigee_km !== null ? `${fmtInt(o.perigee_km)} / ${fmtInt(o.apogee_km)} km` : '—'}</td>132 <td data-label="Inclination" className="num mono text-xs">{fmtDeg(o.inclination_deg)}</td>133 <td data-label="Decayed" className="mono text-xs">{o.decay_date ? fmtDate(o.decay_date) : <span className="text-ink-3">on orbit</span>}</td>134 <td data-label="Operator" className="text-xs">{o.operator_slug ? <Link href={routes.operator(o.operator_slug)} className="link">{o.operator_name}</Link> : o.operator_name ?? '—'}</td>135 </tr>136 ))}137 </tbody>138 </table>139 </div>140 );141 const heading = <span className="inline-flex items-center gap-2 text-sm font-semibold text-ink-2"><TypeBadge type={g.type} /> {OBJECT_TYPE_LABELS[g.type] ?? g.type} <span className="tnum text-ink-3">· {fmtInt(g.rows.length)}</span></span>;142 return collapsed ? (143 <details key={g.type} className="mt-6 first:mt-0">144 <summary className="flex min-h-11 cursor-pointer list-none items-center justify-between gap-3 rounded-md border border-rule px-3 hover:bg-plane-2">145 {heading}146 <span className="text-xs text-ink-3">show all</span>147 </summary>148 <div className="mt-2">{table}</div>149 </details>150 ) : (151 <div key={g.type} className="mt-6 first:mt-0">152 <p className="mb-2">{heading}</p>153 {table}154 </div>155 );156 })}157 </Block>158159 <Block id="details">160 <Head eyebrow="Record" title="Launch record" />161 <DL>162 <Row label="COSPAR launch id" value={l.cospar_launch_id} />163 <Row label="Launch date" value={fmtDate(l.launch_date)} hint="(SATCAT, UTC)" />164 <Row label="Launch site" mono={false} value={l.site_slug ? <Link href={routes.launchSite(l.site_slug)} className="link">{l.site_name}</Link> : l.site_name ?? '—'} />165 <Row label="Site code" value={l.site_code ?? '—'} />166 <Row label="SatelliteIndex id" value={<span className="break-all text-xs">{l.id}</span>} />167 </DL>168 </Block>169170 <Block id="events">171 <Head eyebrow="Timeline" title="Events" />172 {l.events.length ? (173 <ol className="divide-y divide-[color:var(--rule)]">174 {l.events.map((e) => (175 <li key={e.id} className="grid gap-1 py-3 sm:grid-cols-[150px_minmax(0,1fr)]">176 <p className="mono text-xs text-ink-3">{fmtDateTime(e.event_time)}</p>177 <div><span className="mr-2 rounded bg-plane-2 px-1.5 py-px text-[10px] uppercase tracking-wider text-ink-3">{EVENT_TYPE_LABELS[e.type] ?? titleCase(e.type)}</span><span className="text-sm">{e.title}</span>{e.summary && <p className="mt-1 text-xs text-ink-2">{e.summary}</p>}</div>178 </li>179 ))}180 </ol>181 ) : (182 <Empty>No events recorded for this launch or its objects.</Empty>183 )}184 </Block>185 </div>186187 <p className="pb-10 pt-4 text-2xs text-ink-3">188 This launch is derived from catalogued objects sharing the international designator {l.cospar_launch_id}; date and site come from SATCAT. See the <Link href={routes.methodology()} className="hover:text-accent">methodology</Link>.189 </p>190 </Container>191 );192}193